feat(web): the Positions view -- marked at the rails' own price (#701… - #717
Merged
Conversation
… items 1-4) `keel status` showed a tranche's id, product, rule, qty, entry price and bracket, and `web/payload.py::_position_payload` explained in its own docstring why it showed no P&L: "`OpenPositionStatus` carries neither, so emitting one would mean this layer multiplied `qty` by `entry_price`... the fix, if it is wanted, is upstream." This is that fix, upstream, plus the endpoint and view that read it. THE MARK IS THE RAILS' MARK, AND THAT IS THE WHOLE POINT. `agent._mark_to_market_parts` values a holding at `repo.get_candles(product, finest)[-1].close`; `gather_positions` makes the same read through the same `_finest_granularity`. A positions page quoting a different current price would be a second answer to "what is this worth", and since the first answer moved rail 11's drawdown scalars, the page would be the wrong one. That is asserted across the module boundary rather than claimed. The two readers do NOT share a source for QUANTITY: the agent counts inventory from the filled-orders log -- never from `positions`, deliberately, after the phantom-drawdown bug that taught it -- and this report reads the tranche ledger. They share only the mark. So the reconciliation test is what says the two ledgers agree about what is held, and it fails the day they drift. WHAT IS ABSENT STAYS ABSENT. No cached candle means no mark, and no mark means no market value, no unrealized and no stop distance -- never zero. A zero market value renders a held position as a total loss, which is the most alarming thing this page could say and it would be saying it about missing data. `initial_stop` NULL is "not on this row" (a DCA leg, a pre-v12 tranche), so its distance is absent too: measured against a substituted zero it would read as a position comfortably clear of a stop it does not have. Signed where the sign means something, and nowhere else. `unrealized` and `stop_distance` carry verdicts; a market value does not, because an account is not good for being worth something and a glyph on every balance hides the one figure that matters. A negative stop distance means the tranche is trading THROUGH its protection -- keel is cash-spot and long-only, so a stop always sits below the mark and the sign has one meaning. `stop_distance_pct` crosses as a raw fraction with no `%`, the posture `ratio` already documents for the drawdown scalars. THE FRESHNESS CHIP IS THE ENTRY GATE'S VERDICT, not a data age. `missing`/`behind`/ `unconfirmed` are `entry_bar_ready`'s own words for why the agent would refuse to open here. `freshness.assess` tolerates the normal forming-bar lag; `entry_bar_ready` refuses a one-bar-late finer series because that lag is exactly what produces a duplicate real-money order. Showing the softer number would tell a reader the feed is fine while the engine's own gate is refusing it -- and this chip is the most common answer to "why has nothing happened", which is why it sits beside the money rather than under a disclosure. NO CLOSE ACTION, EVER. #701's own refusal, pinned on the source rather than trusted: an exit goes through the typed-phrase friction of the terminal path, because a panic tap on a table row must not be the last line of defence. The test fails the build the day the affordance arrives looking like an obvious convenience. A GUARD FOR A BUG THAT SHIPPED SILENTLY IN THE WRITING OF THIS. The view was first written calling `sorting(sort, onSort)` -- `sorting` is a TYPEDEF and a parameter name in `render.js`, never a function -- and every gate stayed green, because mypy does not read JavaScript, ruff does not either, and the view tests assert over source text rather than executing it. The page would have thrown `ReferenceError` on first render. `test_every_call_resolves_to_something_the_module_has` now scans both derivation-free modules for calls that are not in scope where they are made. Scoped PER FUNCTION, which is the part that took two attempts: the first version pooled every function's parameters into one module-wide set, so `sorting` -- a parameter of `table` and `headerCell` -- counted as defined inside `positionsView`, and the scan passed when the original bug was reinstated. It only came out because the guard was itself mutation-tested against the bug it was written for. Not in this commit, and split rather than faked: #701's attestation chip wants a quarterly expiry that neither `asset_attestations` nor `instrument_attestations` records (every other attestation table in the schema carries `attest_due_ts`; these two do not), and a "Purif: 0.38%" ratio whose denominator nothing holds -- `owed_by_asset` gives dollars, and what they are a percentage OF is a judgement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t had no tests (#701) Review findings on this branch, and the two bugs in the fix for the first one. THE CHIP ASKED THE WRONG QUESTION. `_readiness_for` passed the COARSEST configured granularity to `entry_bar_ready` for every row. But `agent._entry_gate_granularity` gates a rule on the timeframe the RULE DECLARES, and falls back to the coarsest only for a rule that declares none -- and `granularity` is a constructor parameter on `TurtleBreakout`, `PullbackContinuation`, `CusumEvent` and `TripleBarrier`. So a tranche opened by a rule on ONE_HOUR was judged on the daily series: with an hourly feed three bars late and the daily series current, the page rendered a green "entry gate ready" over precisely the state the chip exists to expose. The reverse also held -- a product traded only by hourly rules, with no daily bars cached, read "keel would not open here" while the agent traded it fine. Now resolved through the agent's own function, per row. Two degradations, both deliberate: a `rule_name` nothing matches, and a rules row whose params no longer build, both fall back to the coarsest rather than raising. A chip is not worth a 500, and the fallback is what the agent itself uses for a rule that declares nothing. AND TWO BUGS IN THAT FIX, found by review before it landed. The lookup was keyed on `rules.kind` while `positions.rule_name` holds the rule's `name` -- a separate constructor argument that defaults to the kind. Two `turtle_breakout` rows on different timeframes, a configuration this codebase supports, collapsed to whichever row was read last, and one tranche silently inherited the other's granularity; the test written alongside used two different KINDS and so never covered it. Keyed on the name now, and a name that answers to two different granularities maps to the fallback rather than picking one -- which row opened a given tranche is genuinely unknowable from `rule_name` alone, and a chip that guessed would state it with the same confidence as a resolved one. The second: `_build_rule` ran once per TRANCHE rather than once per rule. It runs the rule's real constructor with its validation, and a DCA book is one rule with many tranches, so per-row building was the common case and not the edge one. Once per rule, pinned by a count. Both consequences of the per-row granularity are fixed too: the readiness cache was keyed on product alone, which handed the second tranche of a product the first one's verdict, and the view rendered one chip per product off `held[0]` -- the same error in the UI. The verdict is a per-tranche column now. THE ENDPOINT WAS EXECUTED BY NO TEST. `/api/positions` never reached `test_api.py`'s `API_ROUTES`, the hand-written tuple every generic endpoint pin is parametrised over -- the envelope, the no-JSON-number walk over the real bytes, the cache headers, the nosniff header, the POST refusal. The payload builder was well covered, and that module's own header says those are not the same statement. Added, with a test asserting the tuple equals `web_api.API_ROUTES` so the next route cannot slip out the same way. THE REFUSAL WAS PARTLY ASSERTING ABOUT ANOTHER VIEW. The view tests sliced a fixed `[:4000]` from `positionsView`, which ran 1362 characters past its end into `ordersView`. #701's central refusal -- no close action, ever -- therefore passed partly because `ordersView`'s first 1362 characters happen to contain none of those words, and would have started failing on an edit to code it does not describe. Sliced to the next `export function`. THE CALL SCANNER HAD BLIND SPOTS, including one that hid functions silently: a default parameter value closed the `([^)]*)` character class early, and the function stopped being scanned at all rather than reporting anything. Module-level arrow functions were never walked. Both fixed, with a floor assertion so a scan that walks nothing cannot pass, a positive control per shape, and the remaining limits -- object and class methods, arrow parameters admitted function-wide -- written down rather than implied. Smaller, all confirmed: `mark_at` returned the bar's timestamp beside a `None` mark, putting a valuation time on a valuation the row denies having; `_READINESS_STATES` sat one character from the pre-existing `_READINESS_STATE` for `VenueReadiness`, an unrelated vocabulary, and is now `_ENTRY_GATE_*`; `stop_distance_pct` rendered at `ratio`'s default two places, so a tranche 0.2% through its stop and one 0.2% above it both showed as "0.00" in exactly the range the column exists to make findable; the `or Decimal("0")` fallbacks on NOT NULL columns are direct reads, so the day one becomes nullable is a loud failure rather than a silent zero -- the substitution `_position_row_to_dict` refuses for `initial_stop`, for the same reason; and `ordersView`'s JSDoc, which the new function had been inserted underneath, is back above `ordersView`. That last one is the identical mistake made and fixed in #700. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
…, items 1-4)
keel statusshowed a tranche's id, product, rule, qty, entry price and bracket, andweb/payload.py::_position_payloadexplained in its own docstring why it showed no P&L: "OpenPositionStatuscarries neither, so emitting one would mean this layer multipliedqtybyentry_price... the fix, if it is wanted, is upstream." This is that fix, upstream, plus the endpoint and view that read it.THE MARK IS THE RAILS' MARK, AND THAT IS THE WHOLE POINT.
agent._mark_to_market_partsvalues a holding atrepo.get_candles(product, finest)[-1].close;gather_positionsmakes the same read through the same_finest_granularity. A positions page quoting a different current price would be a second answer to "what is this worth", and since the first answer moved rail 11's drawdown scalars, the page would be the wrong one.That is asserted across the module boundary rather than claimed. The two readers do NOT share a source for QUANTITY: the agent counts inventory from the filled-orders log -- never from
positions, deliberately, after the phantom-drawdown bug that taught it -- and this report reads the tranche ledger. They share only the mark. So the reconciliation test is what says the two ledgers agree about what is held, and it fails the day they drift.WHAT IS ABSENT STAYS ABSENT. No cached candle means no mark, and no mark means no market value, no unrealized and no stop distance -- never zero. A zero market value renders a held position as a total loss, which is the most alarming thing this page could say and it would be saying it about missing data.
initial_stopNULL is "not on this row" (a DCA leg, a pre-v12 tranche), so its distance is absent too: measured against a substituted zero it would read as a position comfortably clear of a stop it does not have.Signed where the sign means something, and nowhere else.
unrealizedandstop_distancecarry verdicts; a market value does not, because an account is not good for being worth something and a glyph on every balance hides the one figure that matters. A negative stop distance means the tranche is trading THROUGH its protection -- keel is cash-spot and long-only, so a stop always sits below the mark and the sign has one meaning.stop_distance_pctcrosses as a raw fraction with no%, the postureratioalready documents for the drawdown scalars.THE FRESHNESS CHIP IS THE ENTRY GATE'S VERDICT, not a data age.
missing/behind/unconfirmedareentry_bar_ready's own words for why the agent would refuse to open here.freshness.assesstolerates the normal forming-bar lag;entry_bar_readyrefuses a one-bar-late finer series because that lag is exactly what produces a duplicate real-money order. Showing the softer number would tell a reader the feed is fine while the engine's own gate is refusing it -- and this chip is the most common answer to "why has nothing happened", which is why it sits beside the money rather than under a disclosure.NO CLOSE ACTION, EVER. #701's own refusal, pinned on the source rather than trusted: an exit goes through the typed-phrase friction of the terminal path, because a panic tap on a table row must not be the last line of defence. The test fails the build the day the affordance arrives looking like an obvious convenience.
A GUARD FOR A BUG THAT SHIPPED SILENTLY IN THE WRITING OF THIS. The view was first written calling
sorting(sort, onSort)--sortingis a TYPEDEF and a parameter name inrender.js, never a function -- and every gate stayed green, because mypy does not read JavaScript, ruff does not either, and the view tests assert over source text rather than executing it. The page would have thrownReferenceErroron first render.test_every_call_resolves_to_something_the_module_hasnow scans both derivation-free modules for calls that are not in scope where they are made.Scoped PER FUNCTION, which is the part that took two attempts: the first version pooled every function's parameters into one module-wide set, so
sorting-- a parameter oftableandheaderCell-- counted as defined insidepositionsView, and the scan passed when the original bug was reinstated. It only came out because the guard was itself mutation-tested against the bug it was written for.Not in this commit, and split rather than faked: #701's attestation chip wants a quarterly expiry that neither
asset_attestationsnorinstrument_attestationsrecords (every other attestation table in the schema carriesattest_due_ts; these two do not), and a "Purif: 0.38%" ratio whose denominator nothing holds --owed_by_assetgives dollars, and what they are a percentage OF is a judgement.What & why
Tests-first evidence
Gates (all must pass)
uv run ruff checkcleanuv run mypycleanuv run pytest -qgreenScope check
leave checked only if true, and if so: cite the source and open the discussion
BEFORE review (CONTRIBUTING.md, "Governance: rulings vs. machinery").